package com.hmdp.utils;

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.entity.dto.RedisData;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

import static com.hmdp.constant.RedisConstants.*;

/**
 * Redis缓存工具类
 * ● 方法1：将任意Java对象序列化为json并存储在string类型的key中，并且可以设置TTL过期时间
 * ● 方法2：将任意Java对象序列化为json并存储在string类型的key中，并且可以设置逻辑过期时间，用于处理缓存击穿问题
 * ● 方法3：根据指定的key查询缓存，并反序列化为指定类型，利用缓存空值的方式解决缓存穿透问题
 * ● 方法4：根据指定的key查询缓存，并反序列化为指定类型，需要利用逻辑过期解决缓存击穿问题
 * 方法1与方法3对应，负责非热点数据的缓存，利用空值法解决缓存穿透；
 * 方法2与方法4对应，负责热点数据的缓存，利用逻辑过期解决缓存击穿。
 */
@Slf4j
@Component
public class CacheClient {
    private final StringRedisTemplate stringRedisTemplate;

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 将数据加入Redis，并设置有效期
     *
     * @param key   缓存key
     * @param value 缓存数据值
     * @param time  有效时间
     * @param unit  有效时间单位
     */
    public void set(String key, Object value, long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 将数据加入Redis，并设置逻辑过期时间（实际有效期为永久）
     *
     * @param key               缓存key
     * @param value             缓存数据值
     * @param expireTime        逻辑过期时间
     * @param unit              时间单位
     */
    public void setWithLogicalExpire(String key, Object value, long expireTime, TimeUnit unit) {
        // 封装逻辑过期数据（热点数据）
        RedisData redisData = new RedisData();
        // 设置缓存数据
        redisData.setData(value);
        // 设置逻辑过期时间=当前时间+有效期TTL，unit.toSeconds()将时间统一转换为秒
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(expireTime)));
        // 将逻辑过期数据写入Redis，不设置TTL过期时间，key永久有效，真正的过期时间为逻辑过期时间
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 根据id查询数据（使用缓存空值法解决缓存穿透）
     *
     * @param keyPrefix  缓存key前缀
     * @param id         查询id，与缓存key前缀拼接
     * @param type       查询数据的Class类型
     * @param dbFallback 根据id查询数据的函数式接口
     * @param time       有效期
     * @param unit       时间单位
     * @param <R>
     * @param <ID>
     * @return
     */
    public <R, ID> R handleCachePenetrationByBlankValue(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, long time, TimeUnit unit) {
        // 从缓存中查询数据
        String cacheKey = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(cacheKey);
        // 判断缓存是否命中
        if (StrUtil.isNotBlank(json)) {
            // 缓存命中，直接返回
            return JSONUtil.toBean(json, type);
        }
        // 防止缓存穿透：缓存未命中，判断缓存中命中的是否是空值（isNotBlank把null和空字符串给排除了）
        if ("".equals(json)) {
            // 当前数据是空字符串（说明该数据是之前缓存的空对象），返回null
            return null;
        }
        // 缓存未命中，交给调用者查询数据库
        R r = dbFallback.apply(id);
        // 数据库中不存在，将空值写入redis
        if (r == null) {
            stringRedisTemplate.opsForValue().set(cacheKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回null
            return null;
        }
        // 数据库中存在，重建缓存，并返回店铺数据
        this.set(cacheKey, r, time, unit);
        return r;
    }

    /**
     * 缓存重建线程池
     */
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);


    /**
     * 根据id查询热点数据（使用逻辑过期解决缓存击穿）
     *
     * @param cacheKeyPrefix    缓存key前缀
     * @param id                查询id，与缓存key前缀拼接
     * @param type              查询数据的Class类型
     * @param lockKeyPrefix     缓存数据锁前缀，与查询id拼接
     * @param dbFallback        根据id查询数据的函数式接口
     * @param expireTime        逻辑过期时间
     * @param unit              时间单位
     * @param <R>
     * @param <ID>
     * @return
     */
    public <R, ID> R handleCacheBreakdownByLogicalExpire(String cacheKeyPrefix, ID id, Class<R> type, String lockKeyPrefix, Function<ID, R> dbFallback, long expireTime, TimeUnit unit) {
        // 从缓存中获取热点数据
        String cacheKey = cacheKeyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(cacheKey);
        // 判断缓存是否命中（由于是热点数据，提前进行缓存预热，默认缓存一定会命中）
        if (StrUtil.isBlank(json)) {
            // 缓存未命中，说明查到的不是热点key，直接返回空
            return null;
        }
        // 缓存命中，先把json反序列化为逻辑过期对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        // 将Object对象转成JSONObject再反序列化为目标对象
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        // 判断是否逻辑过期
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
            // 未过期，直接返回正确数据
            return r;
        }
        // 已过期，先尝试获取互斥锁，再判断是否需要缓存重建
        String lockKey = lockKeyPrefix + id;
        // 判断是否获取锁成功
        if (tryLock(lockKey)) {
            // 在线程1重建缓存期间，线程2进行过期判断，假设此时key是过期状态，线程1重建完成并释放锁，线程2立刻获取锁，并启动异步线程执行重建，那此时的重建就与线程1的重建重复了
            // 因此需要在线程2获取锁成功后，在这里再次检测redis中缓存是否过期（DoubleCheck），如果未过期则无需重建缓存，防止数据过期之后，刚释放锁就有线程拿到锁的情况，重复访问数据库进行重建
            json = stringRedisTemplate.opsForValue().get(cacheKey);
            // 缓存命中，先把json反序列化为逻辑过期对象
            redisData = JSONUtil.toBean(json, RedisData.class);
            // 将Object对象转成JSONObject再反序列化为目标对象
            r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
            // 判断是否逻辑过期
            if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
                // 命中且未过期，直接返回新数据
                return r;
            }
            // 获取锁成功，开启一个独立子线程去重建缓存
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R result = dbFallback.apply(id);
                    // 写入redis
                    this.setWithLogicalExpire(cacheKey, result, expireTime, unit);
                } finally {
                    // 释放锁
                    unLock(lockKey);
                }
            });
        }
        // 获取锁失败，直接返回过期的旧数据
        return r;
    }

    /**
     * 尝试获取锁，判断是否获取锁成功
     * setIfAbsent()：如果缺失不存在这个key，则可以set，返回true；存在key不能set，返回false。相当于setnx命令
     * @param lockKey 互斥锁的key
     * @return 是否获取到锁
     */
    private boolean tryLock(String lockKey) {
        // 原子命令：set lock value ex 10 nx
        Boolean isGetLock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10L, TimeUnit.SECONDS);
        // 为了避免Boolean直接返回自动拆箱未null，用工具包将null和false都返回为false
        return BooleanUtil.isTrue(isGetLock);
    }

    /**
     * 释放互斥锁
     * @param lockKey 互斥锁的key
     */
    private void unLock(String lockKey) {
        if (BooleanUtil.isTrue(stringRedisTemplate.hasKey(lockKey))) {
            stringRedisTemplate.delete(lockKey);
        }
    }
}
